Из «Бета-Банка» стали уходить клиенты. Каждый месяц. Немного, но заметно. Банковские маркетологи посчитали: сохранять текущих клиентов дешевле, чем привлекать новых.
Нужно спрогнозировать, уйдёт клиент из банка в ближайшее время или нет. Вам предоставлены исторические данные о поведении клиентов и расторжении договоров с банком.
Постройте модель с предельно большим значением F1-меры. Чтобы сдать проект успешно, нужно довести метрику до 0.59. Проверьте F1-меру на тестовой выборке самостоятельно.
Дополнительно измеряйте AUC-ROC, сравнивайте её значение с F1-мерой.
Источник данных: https://www.kaggle.com/barelydedicated/bank-customer-churn-modeling
Цели:
Провести исследование с целью прогнозирования ухода клиентов из «Бета-Банка» в ближайшее время.
Результаты исследования позволят маркетологам сохранить текущих клиентов, т.к. это дешевле, чем привлекать новых.
Задачи:
Нам предоставлены исторические данные о поведении клиентов и расторжении договоров с банком.
Данные находятся в файле Churn.csv (англ. «отток клиентов»).
Источник данных: https://www.kaggle.com/barelydedicated/bank-customer-churn-modeling
Признаки:
RowNumber
— индекс строки в данных
CustomerId
— уникальный идентификатор клиента
Surname
— фамилия
CreditScore
— кредитный рейтинг
Geography
— страна проживания
Gender
— пол
Age
— возраст
Tenure
— сколько лет человек является клиентом банка
Balance
— баланс на счёте
NumOfProducts
— количество продуктов банка, используемых клиентом
HasCrCard
— наличие кредитной карты
IsActiveMember
— активность клиента
EstimatedSalary
— предполагаемая зарплата
Целевой признак:
Exited
— факт ухода клиента
pip install skimpy
Requirement already satisfied: skimpy in /opt/conda/lib/python3.9/site-packages (0.0.8) Requirement already satisfied: pandas<2.0.0,>=1.3.2 in /opt/conda/lib/python3.9/site-packages (from skimpy) (1.5.3) Requirement already satisfied: rich<13.0,>=10.9 in /opt/conda/lib/python3.9/site-packages (from skimpy) (12.6.0) Requirement already satisfied: ipykernel<7.0.0,>=6.7.0 in /opt/conda/lib/python3.9/site-packages (from skimpy) (6.22.0) Requirement already satisfied: typeguard<3.0.0,>=2.12.1 in /opt/conda/lib/python3.9/site-packages (from skimpy) (2.13.3) Requirement already satisfied: jupyter<2.0.0,>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from skimpy) (1.0.0) Requirement already satisfied: click<9.0.0,>=8.1.3 in /opt/conda/lib/python3.9/site-packages (from skimpy) (8.1.3) Requirement already satisfied: numpy<2.0.0,>=1.22.2 in /opt/conda/lib/python3.9/site-packages (from skimpy) (1.23.5) Requirement already satisfied: Pygments<3.0.0,>=2.10.0 in /opt/conda/lib/python3.9/site-packages (from skimpy) (2.14.0) Requirement already satisfied: jupyter-core!=5.0.*,>=4.12 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (5.3.0) Requirement already satisfied: traitlets>=5.4.0 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (5.9.0) Requirement already satisfied: comm>=0.1.1 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (0.1.3) Requirement already satisfied: ipython>=7.23.1 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (7.25.0) Requirement already satisfied: packaging in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (21.3) Requirement already satisfied: nest-asyncio in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (1.5.1) Requirement already satisfied: tornado>=6.1 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (6.1) Requirement already satisfied: matplotlib-inline>=0.1 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (0.1.2) Requirement already satisfied: pyzmq>=20 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (22.1.0) Requirement already satisfied: psutil in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (5.9.4) Requirement already satisfied: debugpy>=1.6.5 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (1.6.6) Requirement already satisfied: jupyter-client>=6.1.12 in /opt/conda/lib/python3.9/site-packages (from ipykernel<7.0.0,>=6.7.0->skimpy) (6.1.12) Requirement already satisfied: setuptools>=18.5 in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (49.6.0.post20210108) Requirement already satisfied: backcall in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.2.0) Requirement already satisfied: pickleshare in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.7.5) Requirement already satisfied: decorator in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (5.0.9) Requirement already satisfied: jedi>=0.16 in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.18.0) Requirement already satisfied: pexpect>4.3 in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (4.8.0) Requirement already satisfied: prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0 in /opt/conda/lib/python3.9/site-packages (from ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (3.0.19) Requirement already satisfied: parso<0.9.0,>=0.8.0 in /opt/conda/lib/python3.9/site-packages (from jedi>=0.16->ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.8.2) Requirement already satisfied: jupyter-console in /opt/conda/lib/python3.9/site-packages (from jupyter<2.0.0,>=1.0.0->skimpy) (6.4.2) Requirement already satisfied: nbconvert in /opt/conda/lib/python3.9/site-packages (from jupyter<2.0.0,>=1.0.0->skimpy) (6.1.0) Requirement already satisfied: qtconsole in /opt/conda/lib/python3.9/site-packages (from jupyter<2.0.0,>=1.0.0->skimpy) (5.3.2) Requirement already satisfied: ipywidgets in /opt/conda/lib/python3.9/site-packages (from jupyter<2.0.0,>=1.0.0->skimpy) (7.6.3) Requirement already satisfied: notebook in /opt/conda/lib/python3.9/site-packages (from jupyter<2.0.0,>=1.0.0->skimpy) (6.4.0) Requirement already satisfied: python-dateutil>=2.1 in /opt/conda/lib/python3.9/site-packages (from jupyter-client>=6.1.12->ipykernel<7.0.0,>=6.7.0->skimpy) (2.8.1) Requirement already satisfied: platformdirs>=2.5 in /opt/conda/lib/python3.9/site-packages (from jupyter-core!=5.0.*,>=4.12->ipykernel<7.0.0,>=6.7.0->skimpy) (3.2.0) Requirement already satisfied: pytz>=2020.1 in /opt/conda/lib/python3.9/site-packages (from pandas<2.0.0,>=1.3.2->skimpy) (2021.1) Requirement already satisfied: ptyprocess>=0.5 in /opt/conda/lib/python3.9/site-packages (from pexpect>4.3->ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.7.0) Requirement already satisfied: wcwidth in /opt/conda/lib/python3.9/site-packages (from prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0->ipython>=7.23.1->ipykernel<7.0.0,>=6.7.0->skimpy) (0.2.5) Requirement already satisfied: six>=1.5 in /opt/conda/lib/python3.9/site-packages (from python-dateutil>=2.1->jupyter-client>=6.1.12->ipykernel<7.0.0,>=6.7.0->skimpy) (1.16.0) Requirement already satisfied: commonmark<0.10.0,>=0.9.0 in /opt/conda/lib/python3.9/site-packages (from rich<13.0,>=10.9->skimpy) (0.9.1) Requirement already satisfied: nbformat>=4.2.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (5.1.3) Requirement already satisfied: widgetsnbextension~=3.5.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (3.5.2) Requirement already satisfied: jupyterlab-widgets>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (3.0.2) Requirement already satisfied: jsonschema!=2.5.0,>=2.4 in /opt/conda/lib/python3.9/site-packages (from nbformat>=4.2.0->ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (3.2.0) Requirement already satisfied: ipython-genutils in /opt/conda/lib/python3.9/site-packages (from nbformat>=4.2.0->ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (0.2.0) Requirement already satisfied: attrs>=17.4.0 in /opt/conda/lib/python3.9/site-packages (from jsonschema!=2.5.0,>=2.4->nbformat>=4.2.0->ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (21.2.0) Requirement already satisfied: pyrsistent>=0.14.0 in /opt/conda/lib/python3.9/site-packages (from jsonschema!=2.5.0,>=2.4->nbformat>=4.2.0->ipywidgets->jupyter<2.0.0,>=1.0.0->skimpy) (0.17.3) Requirement already satisfied: Send2Trash>=1.5.0 in /opt/conda/lib/python3.9/site-packages (from notebook->jupyter<2.0.0,>=1.0.0->skimpy) (1.7.1) Requirement already satisfied: argon2-cffi in /opt/conda/lib/python3.9/site-packages (from notebook->jupyter<2.0.0,>=1.0.0->skimpy) (20.1.0) Requirement already satisfied: terminado>=0.8.3 in /opt/conda/lib/python3.9/site-packages (from notebook->jupyter<2.0.0,>=1.0.0->skimpy) (0.10.1) Requirement already satisfied: prometheus-client in /opt/conda/lib/python3.9/site-packages (from notebook->jupyter<2.0.0,>=1.0.0->skimpy) (0.11.0) Requirement already satisfied: jinja2 in /opt/conda/lib/python3.9/site-packages (from notebook->jupyter<2.0.0,>=1.0.0->skimpy) (3.0.1) Requirement already satisfied: cffi>=1.0.0 in /opt/conda/lib/python3.9/site-packages (from argon2-cffi->notebook->jupyter<2.0.0,>=1.0.0->skimpy) (1.14.5) Requirement already satisfied: pycparser in /opt/conda/lib/python3.9/site-packages (from cffi>=1.0.0->argon2-cffi->notebook->jupyter<2.0.0,>=1.0.0->skimpy) (2.20) Requirement already satisfied: MarkupSafe>=2.0 in /opt/conda/lib/python3.9/site-packages (from jinja2->notebook->jupyter<2.0.0,>=1.0.0->skimpy) (2.1.1) Requirement already satisfied: defusedxml in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.7.1) Requirement already satisfied: testpath in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.5.0) Requirement already satisfied: nbclient<0.6.0,>=0.5.0 in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.5.3) Requirement already satisfied: jupyterlab-pygments in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.1.2) Requirement already satisfied: entrypoints>=0.2.2 in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.3) Requirement already satisfied: mistune<2,>=0.8.1 in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.8.4) Requirement already satisfied: pandocfilters>=1.4.1 in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (1.4.2) Requirement already satisfied: bleach in /opt/conda/lib/python3.9/site-packages (from nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (3.3.0) Requirement already satisfied: async-generator in /opt/conda/lib/python3.9/site-packages (from nbclient<0.6.0,>=0.5.0->nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (1.10) Requirement already satisfied: webencodings in /opt/conda/lib/python3.9/site-packages (from bleach->nbconvert->jupyter<2.0.0,>=1.0.0->skimpy) (0.5.1) Requirement already satisfied: pyparsing!=3.0.5,>=2.0.2 in /opt/conda/lib/python3.9/site-packages (from packaging->ipykernel<7.0.0,>=6.7.0->skimpy) (2.4.7) Requirement already satisfied: qtpy>=2.0.1 in /opt/conda/lib/python3.9/site-packages (from qtconsole->jupyter<2.0.0,>=1.0.0->skimpy) (2.2.0) Note: you may need to restart the kernel to use updated packages.
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from tqdm import tqdm_notebook
from skimpy import clean_columns
from sklearn.utils import shuffle
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.metrics import accuracy_score, confusion_matrix, recall_score, precision_recall_curve
from sklearn.metrics import precision_score, f1_score, roc_curve, roc_auc_score
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)
# снимаем ограничение на ширину столбцов
#pd.set_option('display.max_colwidth', None)
# игнорируем предупреждения
pd.set_option('chained_assignment', None)
# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.2f}'.format
# устанавливаем стиль графиков
sns.set_style('darkgrid')
sns.set(rc={'figure.dpi':200, 'savefig.dpi':300})
sns.set_context('notebook')
sns.set_style('ticks')
try:
data = pd.read_csv('/datasets/Churn.csv')
except:
data = pd.read_csv('Churn.csv')
display(
data.head(), data.sample(5), data.tail())
RowNumber | CustomerId | Surname | CreditScore | Geography | Gender | Age | Tenure | Balance | NumOfProducts | HasCrCard | IsActiveMember | EstimatedSalary | Exited | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 15634602 | Hargrave | 619 | France | Female | 42 | 2.00 | 0.00 | 1 | 1 | 1 | 101,348.88 | 1 |
1 | 2 | 15647311 | Hill | 608 | Spain | Female | 41 | 1.00 | 83,807.86 | 1 | 0 | 1 | 112,542.58 | 0 |
2 | 3 | 15619304 | Onio | 502 | France | Female | 42 | 8.00 | 159,660.80 | 3 | 1 | 0 | 113,931.57 | 1 |
3 | 4 | 15701354 | Boni | 699 | France | Female | 39 | 1.00 | 0.00 | 2 | 0 | 0 | 93,826.63 | 0 |
4 | 5 | 15737888 | Mitchell | 850 | Spain | Female | 43 | 2.00 | 125,510.82 | 1 | 1 | 1 | 79,084.10 | 0 |
RowNumber | CustomerId | Surname | CreditScore | Geography | Gender | Age | Tenure | Balance | NumOfProducts | HasCrCard | IsActiveMember | EstimatedSalary | Exited | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
6254 | 6255 | 15721047 | Ansell | 578 | Germany | Male | 37 | 1.00 | 135,650.88 | 1 | 1 | 0 | 199,428.19 | 0 |
7045 | 7046 | 15648069 | Onyemachukwu | 850 | France | Female | 36 | 6.00 | 0.00 | 2 | 1 | 1 | 190,194.95 | 0 |
8558 | 8559 | 15774507 | Furneaux | 574 | France | Female | 39 | 5.00 | 119,013.86 | 1 | 1 | 0 | 103,421.91 | 0 |
362 | 363 | 15706365 | Bianchi | 648 | France | Female | 50 | 9.00 | 102,535.57 | 1 | 1 | 1 | 189,543.19 | 0 |
8869 | 8870 | 15733597 | Y?an | 669 | France | Female | 41 | 0.00 | 150,219.41 | 2 | 0 | 0 | 107,839.03 | 0 |
RowNumber | CustomerId | Surname | CreditScore | Geography | Gender | Age | Tenure | Balance | NumOfProducts | HasCrCard | IsActiveMember | EstimatedSalary | Exited | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
9995 | 9996 | 15606229 | Obijiaku | 771 | France | Male | 39 | 5.00 | 0.00 | 2 | 1 | 0 | 96,270.64 | 0 |
9996 | 9997 | 15569892 | Johnstone | 516 | France | Male | 35 | 10.00 | 57,369.61 | 1 | 1 | 1 | 101,699.77 | 0 |
9997 | 9998 | 15584532 | Liu | 709 | France | Female | 36 | 7.00 | 0.00 | 1 | 0 | 1 | 42,085.58 | 1 |
9998 | 9999 | 15682355 | Sabbatini | 772 | Germany | Male | 42 | 3.00 | 75,075.31 | 2 | 1 | 0 | 92,888.52 | 1 |
9999 | 10000 | 15628319 | Walker | 792 | France | Female | 28 | NaN | 130,142.79 | 1 | 1 | 0 | 38,190.78 | 0 |
# Дубликаты
data.duplicated().sum()
0
data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 10000 entries, 0 to 9999 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 RowNumber 10000 non-null int64 1 CustomerId 10000 non-null int64 2 Surname 10000 non-null object 3 CreditScore 10000 non-null int64 4 Geography 10000 non-null object 5 Gender 10000 non-null object 6 Age 10000 non-null int64 7 Tenure 9091 non-null float64 8 Balance 10000 non-null float64 9 NumOfProducts 10000 non-null int64 10 HasCrCard 10000 non-null int64 11 IsActiveMember 10000 non-null int64 12 EstimatedSalary 10000 non-null float64 13 Exited 10000 non-null int64 dtypes: float64(3), int64(8), object(3) memory usage: 1.1+ MB
# Пропущенные значения
data.isna().sum()
RowNumber 0 CustomerId 0 Surname 0 CreditScore 0 Geography 0 Gender 0 Age 0 Tenure 909 Balance 0 NumOfProducts 0 HasCrCard 0 IsActiveMember 0 EstimatedSalary 0 Exited 0 dtype: int64
data['Tenure'].describe()
count 9,091.00 mean 5.00 std 2.89 min 0.00 25% 2.00 50% 5.00 75% 7.00 max 10.00 Name: Tenure, dtype: float64
# Видим, что среднее и медианное значение равны, т.е. нет никакой разинцы чем запонить пропущенные значения
# Также предлагаю изменить тип данных на int
data['Tenure'] = data['Tenure'].fillna(data.Tenure.median()).astype('int')
# Предлагаю удалить первые три колонки, т.к. они не несут никакой ценности
data = data.drop(['RowNumber', 'CustomerId', 'Surname'], axis=1)
data.head()
CreditScore | Geography | Gender | Age | Tenure | Balance | NumOfProducts | HasCrCard | IsActiveMember | EstimatedSalary | Exited | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 619 | France | Female | 42 | 2 | 0.00 | 1 | 1 | 1 | 101,348.88 | 1 |
1 | 608 | Spain | Female | 41 | 1 | 83,807.86 | 1 | 0 | 1 | 112,542.58 | 0 |
2 | 502 | France | Female | 42 | 8 | 159,660.80 | 3 | 1 | 0 | 113,931.57 | 1 |
3 | 699 | France | Female | 39 | 1 | 0.00 | 2 | 0 | 0 | 93,826.63 | 0 |
4 | 850 | Spain | Female | 43 | 2 | 125,510.82 | 1 | 1 | 1 | 79,084.10 | 0 |
# Приведем наименования столбцов к хорошему стилю
data = clean_columns(data)
data.head()
11 column names have been cleaned
credit_score | geography | gender | age | tenure | balance | num_of_products | has_cr_card | is_active_member | estimated_salary | exited | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 619 | France | Female | 42 | 2 | 0.00 | 1 | 1 | 1 | 101,348.88 | 1 |
1 | 608 | Spain | Female | 41 | 1 | 83,807.86 | 1 | 0 | 1 | 112,542.58 | 0 |
2 | 502 | France | Female | 42 | 8 | 159,660.80 | 3 | 1 | 0 | 113,931.57 | 1 |
3 | 699 | France | Female | 39 | 1 | 0.00 | 2 | 0 | 0 | 93,826.63 | 0 |
4 | 850 | Spain | Female | 43 | 2 | 125,510.82 | 1 | 1 | 1 | 79,084.10 | 0 |
data['geography'].value_counts()
France 5014 Germany 2509 Spain 2477 Name: geography, dtype: int64
data['gender'].value_counts()
Male 5457 Female 4543 Name: gender, dtype: int64
Промежуточный вывод:
Tenure
заполнили медианным значением и изменили тип данных на int, явных дубликатов нет.RowNumber
, CustomerId
, Surname
# Преобразуем категориальные значения в численные с помощью техники прямого кодирования (One-Hot Encoding, OHE)
df_ohe = pd.get_dummies(data, drop_first=True, columns=['geography', 'gender'])
df_ohe
credit_score | age | tenure | balance | num_of_products | has_cr_card | is_active_member | estimated_salary | exited | geography_Germany | geography_Spain | gender_Male | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 619 | 42 | 2 | 0.00 | 1 | 1 | 1 | 101,348.88 | 1 | 0 | 0 | 0 |
1 | 608 | 41 | 1 | 83,807.86 | 1 | 0 | 1 | 112,542.58 | 0 | 0 | 1 | 0 |
2 | 502 | 42 | 8 | 159,660.80 | 3 | 1 | 0 | 113,931.57 | 1 | 0 | 0 | 0 |
3 | 699 | 39 | 1 | 0.00 | 2 | 0 | 0 | 93,826.63 | 0 | 0 | 0 | 0 |
4 | 850 | 43 | 2 | 125,510.82 | 1 | 1 | 1 | 79,084.10 | 0 | 0 | 1 | 0 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
9995 | 771 | 39 | 5 | 0.00 | 2 | 1 | 0 | 96,270.64 | 0 | 0 | 0 | 1 |
9996 | 516 | 35 | 10 | 57,369.61 | 1 | 1 | 1 | 101,699.77 | 0 | 0 | 0 | 1 |
9997 | 709 | 36 | 7 | 0.00 | 1 | 0 | 1 | 42,085.58 | 1 | 0 | 0 | 0 |
9998 | 772 | 42 | 3 | 75,075.31 | 2 | 1 | 0 | 92,888.52 | 1 | 1 | 0 | 1 |
9999 | 792 | 28 | 5 | 130,142.79 | 1 | 1 | 0 | 38,190.78 | 0 | 0 | 0 | 0 |
10000 rows × 12 columns
#encoder = OneHotEncoder(handle_unknown='ignore', drop='first') #удаляем первый столбец чтобы не попасть в дамми-ловушку
#features_train_ohe = pd.DataFrame(
#encoder.fit_transform(features_train[geography]).toarray(),
#columns=encoder.get_feature_names_out()
#)
#features_valid_ohe = pd.DataFrame(
#encoder.transform(features_valid[var_categorical]).toarray(),
#columns=encoder.get_feature_names_out()
#)
#
#features_test_ohe = pd.DataFrame(
#encoder.transform(features_test[var_categorical]).toarray(),
#columns=encoder.get_feature_names_out()
#)
# Выделим из датасета target(целевой признак) и features(все остальные признаки)
features = df_ohe.drop('exited', axis=1)
target = df_ohe['exited']
# Предлагаю разделить исходный датасет на обучающую, валидационную и тестовую выборки в пропорции 3:1:1
# Применим агрумент stratify для корректного разбиения датасета
# Разделим данные на промежуточную и тестовую(20%)
features_interim, features_test, target_interim, target_test = train_test_split(
features, target, test_size=0.20, random_state=12345, stratify=target)
# Теперь промежуточную разделим на обучающую и валидационную
features_train, features_valid, target_train, target_valid = train_test_split(
features_interim, target_interim, test_size=0.25, random_state=12345, stratify=target_interim)
# Проигнорим предупреждение SettingWithCopy
pd.options.mode.chained_assignment = None
numeric = ['credit_score', 'age', 'tenure', 'balance', 'estimated_salary']
# Создадим объект структруы StandardScaler
scaler = StandardScaler()
# Настроим объект на обучающих данных (настройка - это вычисление среднего и дисперсии)
scaler.fit(features_train[numeric])
# Преобразуем обучающую выборку функцией transform()
# то есть нормируем значение признаков, все значения становятся в диапозоне от 0 до 1)
features_train[numeric] = scaler.transform(features_train[numeric])
features_valid[numeric] = scaler.transform(features_valid[numeric])
features_test[numeric] = scaler.transform(features_test[numeric])
print(features_train.shape)
print(features_valid.shape)
print(features_test.shape)
(6000, 11) (2000, 11) (2000, 11)
Комментарий:
Данные подготовлены для исследовательской работы
Задание №2. Исследуйте баланс классов, обучите модель без учёта дисбаланса. Кратко опишите выводы.
# Посмотрим на значения оттока и оставшихся клиентов:
data['exited'].value_counts()
0 7963 1 2037 Name: exited, dtype: int64
# Посмотрим как зависит возраст и отток клиентов
data.groupby('age').agg({'exited':'sum'}).plot(kind='bar', title='Гистограмма возраста и оттока', figsize=(15,6))
plt.xlabel('Возраст')
plt.ylabel('Отток клиентов')
plt.show()
# Посмотрим с какой страны больше отток клиентов
data.groupby('geography').agg({'exited':'sum'}).plot(kind='bar', title='Гистограмма возраста и оттока', figsize=(15,6))
plt.xlabel('Страна')
plt.ylabel('Отток клиентов')
plt.show()
Комментарий:
Мы видим, что состав классов в таргете показывает, что количество ушедших клиентов (положительных ответов) почти в 4 раза меньше чем оставшихся в банке. И по гистограмме распределения видно, что отток зависит от возраста и распределено нормально. Больше всего уходит клиентов в возрасте от 37 до 50 лет, c Франции и Германии
%%time
best_model = None
best_depth = 0
best_f1 = 0
best_auc_roc = 0
for depth in tqdm_notebook(range(1, 11)):
# инициализируем модель - дерево решений
model = DecisionTreeClassifier(random_state=12345, max_depth=depth)
# обучим модель на обучающей выборке
model.fit(features_train, target_train)
prediction_train = pd.Series(model.predict(features_train))
# F1 для дерева решений на обуч.выборке
f1_train = f1_score(target_train, prediction_train)
# предсказание модели на валидационной выборке
prediction_valid = pd.Series(model.predict(features_valid))
# F1 для дерева решений на валидационной.выборке
f1_valid = f1_score(target_valid, prediction_valid)
# Найдем вероятность классов на валидационной выборке
probabilities_valid = model.predict_proba(features_valid)
# Сохраним значения класса 1
probab_one_valid = probabilities_valid[:, 1]
# AUC-ROC для модели дерева решений
roc_auc_valid = roc_auc_score(target_valid, probab_one_valid)
if f1_valid > best_f1:
best_model = model
best_depth = depth
best_f1 = f1_valid
best_auc_roc = roc_auc_valid
print('Наилучшая модель "дерево решений" для валидационной выборки:', best_model)
print('Глубина дерева наилучшей модели "дерево решений" для валидационной выборки:', best_depth)
print('F1-мера наилучшей модели "дерево решений" для валидационной выборки:', best_f1)
print('AUC-ROC наилучшей модели "дерево решений" для валидационной выборки:', best_auc_roc)
<timed exec>:6: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0 Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`
0%| | 0/10 [00:00<?, ?it/s]
Наилучшая модель "дерево решений" для валидационной выборки: DecisionTreeClassifier(max_depth=8, random_state=12345) Глубина дерева наилучшей модели "дерево решений" для валидационной выборки: 8 F1-мера наилучшей модели "дерево решений" для валидационной выборки: 0.5816618911174785 AUC-ROC наилучшей модели "дерево решений" для валидационной выборки: 0.8097265215909283 CPU times: user 242 ms, sys: 10.9 ms, total: 253 ms Wall time: 271 ms
Комментарий:
Видим, что F1-мера наилучшей модели для валидационной выборки состовляет = 0.5816618911174785
AUC-ROC = 0.8097265215909283 соответственно, где глубина состовляла 8
%%time
best_model = None
best_depth = 0
best_est = 0
best_f1 = 0
best_auc_roc = 0
for depth in tqdm_notebook(range(1, 11)):
for est in range(1, 101):
# инициализируем модель - дерево решений
model_rf = RandomForestClassifier(max_depth=depth, n_estimators=est, random_state=12345)
# обучим модель на обучающей выборке
model_rf.fit(features_train, target_train)
prediction_train = pd.Series(model_rf.predict(features_train))
# F1 для дерева решений на обуч.выборке
f1_train = f1_score(target_train, prediction_train)
# предсказание модели на валидационной выборке
prediction_valid = pd.Series(model_rf.predict(features_valid))
# F1 для дерева решений на валидационной.выборке
f1_valid = f1_score(target_valid, prediction_valid)
# Найдем вероятность классов на валидационной выборке
probabilities_valid = model.predict_proba(features_valid)
# Сохраним значения класса 1
probab_one_valid = probabilities_valid[:, 1]
# AUC-ROC для модели дерева решений
roc_auc_valid = roc_auc_score(target_valid, probab_one_valid)
if f1_valid > best_f1:
best_model = model
best_depth = depth
best_est = est
best_f1 = f1_valid
best_auc_roc = roc_auc_valid
print('Наилучшая модель "случайный лес" для валидационной выборки:', best_model)
print('Количество деревьев наилучшей модели "случайный лес" для валидационной выборки:', best_est)
print('Глубина дерева наилучшей модели "случайный лес" для валидационной выборки:', best_depth)
print('F1-мера наилучшей модели "случайный лес" для валидационной выборки:', best_f1)
print('AUC-ROC наилучшей модели "случайный лес" для валидационной выборки:', best_auc_roc)
<timed exec>:7: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0 Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`
0%| | 0/10 [00:00<?, ?it/s]
Наилучшая модель "случайный лес" для валидационной выборки: DecisionTreeClassifier(max_depth=10, random_state=12345) Количество деревьев наилучшей модели "случайный лес" для валидационной выборки: 39 Глубина дерева наилучшей модели "случайный лес" для валидационной выборки: 10 F1-мера наилучшей модели "случайный лес" для валидационной выборки: 0.5709677419354838 AUC-ROC наилучшей модели "случайный лес" для валидационной выборки: 0.778875177180262 CPU times: user 4min 12s, sys: 810 ms, total: 4min 13s Wall time: 4min 13s
Комментарий:
Здесь мы видим, что F1 у случайного леса чуть выше дерева решений, что состовляет 0.6450116009280743
%%time
model = LogisticRegression(random_state=12345, solver='lbfgs', max_iter=1000)
model.fit(features_train, target_train)
prediction_valid = pd.Series(model.predict(features_valid))
f1_valid = f1_score(target_valid, prediction_valid)
probab_valid = model.predict_proba(features_valid)
probab_valid_one = probab_valid[:, 1]
roc_auc_valid = roc_auc_score(target_valid, probab_valid_one)
print("F1-мера модели 'логистическая регрессия' на валидационной выборке:", f1_valid)
print("AUC-ROC модели 'логистическая регрессия' на валидационной выборке:", roc_auc_valid)
F1-мера модели 'логистическая регрессия' на валидационной выборке: 0.3214953271028037 AUC-ROC модели 'логистическая регрессия' на валидационной выборке: 0.7875656858707706 CPU times: user 133 ms, sys: 144 ms, total: 277 ms Wall time: 285 ms
Комментарий:
Значение F1 у логистической регрессии наименьшее = 0.3214953271028037
Так, по итогу при дисбалансе классов лидирует модель слуйчаный лес
#2. Выведем баланс классов в таргете на обучающей выборке:
target_train.value_counts()
0 4777 1 1223 Name: exited, dtype: int64
# расчитаем матрицу ошибок для модели случайного леса
predictions_train = pd.Series(model_rf.predict(features_train))
confusion_matrix(target_train, predictions_train)
array([[4747, 30], [ 579, 644]])
# 2. Выведем баланс классов в таргете на валидационной выборке:
target_valid.value_counts()
0 1593 1 407 Name: exited, dtype: int64
predictions_valid = pd.Series(model_rf.predict(features_valid))
confusion_matrix(target_valid, predictions_valid)
array([[1559, 34], [ 231, 176]])
Очевидно, что на валидационной выборке при дисбалансе классов модель случайного леса с циклом for часто видит отрицательные ответы там, где их нет: ложноотрицательные ответы составляют 37% от общего количества положительных ответов
#X = data.drop("exited",axis=1)
#y = data["exited"]
#X_sm,y_sm = smote.fit_resample(X.astype(float),y)
#x_train,x_test,y_train,y_test = train_test_split(X_sm,y_sm,test_size=0.2,random_state=42)
#len(x_train),len(x_test),len(y_train),len(y_test)
Задание №3. Улучшите качество модели, учитывая дисбаланс классов. Обучите разные модели и найдите лучшую. Кратко опишите выводы.
Проделаем те же самые действия, что и в задании 2, но при этом улучшим качество моделей при помощи:
class_weight='balanced'
,# Функция для улучшения качества модели с помощью увеличения выборки
def upsample(features, target, repeat):
features_zeros = features[target == 0]
features_ones = features[target == 1]
target_zeros = target[target == 0]
target_ones = target[target == 1]
# Объединим
repeat = 2
features_upsampled = pd.concat([features_zeros] + [features_ones] * repeat)
target_upsampled = pd.concat([target_zeros] + [target_ones] * repeat)
# Перемещаем данные
features_upsampled, target_upsampled = shuffle(features_upsampled, target_upsampled, random_state=12345)
return features_upsampled, target_upsampled
# Протестируем функцию
features_train_upsampled, target_train_upsampled = upsample(features_train, target_train, 2)
print(features_train_upsampled.shape)
print(target_train_upsampled.shape)
(7223, 11) (7223,)
# Функция для уменьшения представленной класса в выборке
def downsample(features, target, fraction):
features_zeros = features[target == 0]
features_ones = features[target == 1]
target_zeros = target[target == 0]
target_ones = target[target == 1]
# Объединим
frection = 0.5
features_downsample = pd.concat([features_zeros.sample(frac=fraction, random_state = 12345)] + [features_ones])
target_downsample = pd.concat([target_zeros.sample(frac=fraction, random_state = 12345)] + [target_ones])
# Перемещаем данные
features_downsample, target_downsample = shuffle(features_downsample, target_downsample, random_state=12345)
return features_downsample, target_downsample
# Протестируем функцию
features_train_downsampled, target_train_downsampled = downsample(features_train_upsampled,
target_train_upsampled, 0.5)
print(features_train_downsampled.shape)
print(target_train_downsampled.shape)
(4834, 11) (4834,)
target_train_downsampled.value_counts(normalize=True)
1 0.51 0 0.49 Name: exited, dtype: float64
%%time
best_model = None
best_depth = 0
best_f1 = 0
best_auc_roc = 0
for depth in tqdm_notebook(range(1, 11)):
# инициализируем модель - дерево решений
model = DecisionTreeClassifier(random_state=12345, max_depth=depth)
# обучим модель на обучающей выборке
model.fit(features_train_downsampled, target_train_downsampled)
prediction_train = pd.Series(model.predict(features_train_downsampled))
# F1 для дерева решений на обуч.выборке
f1_train = f1_score(target_train_downsampled, prediction_train)
# предсказание модели на валидационной выборке
prediction_valid = pd.Series(model.predict(features_valid))
# F1 для дерева решений на валидационной.выборке
f1_valid = f1_score(target_valid, prediction_valid)
# найдем вероятность классов на валидационной выборке
probabilities_valid = model.predict_proba(features_valid)
# сохраним значения класса 1
probab_one_valid = probabilities_valid[:, 1]
# AUC-ROC для модели дерева решений
roc_auc_valid = roc_auc_score(target_valid, probab_one_valid)
if f1_valid > best_f1:
best_model = model
best_depth = depth
best_f1 = f1_valid
best_auc_roc = roc_auc_valid
print('Наилучшая модель "дерево решений" для валидационной выборки:', best_model)
print('Глубина дерева наилучшей модели "дерево решений" для валидационной выборки:', best_depth)
print('F1-мера наилучшей модели "дерево решений" для валидационной выборки:', best_f1)
print('AUC-ROC наилучшей модели "дерево решений" для валидационной выборки:', best_auc_roc)
<timed exec>:6: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0 Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`
0%| | 0/10 [00:00<?, ?it/s]
Наилучшая модель "дерево решений" для валидационной выборки: DecisionTreeClassifier(max_depth=8, random_state=12345) Глубина дерева наилучшей модели "дерево решений" для валидационной выборки: 8 F1-мера наилучшей модели "дерево решений" для валидационной выборки: 0.5697560975609756 AUC-ROC наилучшей модели "дерево решений" для валидационной выборки: 0.8000766560088595 CPU times: user 218 ms, sys: 16 µs, total: 218 ms Wall time: 221 ms
Комментарий:
Наилучшая модель "дерево решений" на валидационной выборке имеет F1-меру = 0.5697560975609756
AUC-ROC = 0.8000766560088595 при глубине дерева depth = 7. Т.е. F1-мера не превышает заданный порог 0,59.
%%time
best_model = None
best_depth = 0
best_est = 0
best_f1 = 0
best_auc_roc = 0
for depth in tqdm_notebook(range(1, 11)):
for est in range(1, 101):
# инициализируем модель - дерево решений
model = RandomForestClassifier(max_depth=depth, n_estimators=est, random_state=12345)
# обучим модель на обучающей выборке
model.fit(features_train_downsampled, target_train_downsampled)
prediction_train = pd.Series(model.predict(features_train))
# F1 для дерева решений на обуч.выборке
f1_train = f1_score(target_train, prediction_train)
# предсказание модели на валидационной выборке
prediction_valid = pd.Series(model.predict(features_valid))
# F1 для дерева решений на валидационной.выборке
f1_valid = f1_score(target_valid, prediction_valid)
# найдем вероятность классов на валидационной выборке
probabilities_valid = model.predict_proba(features_valid)
# cохраним значения класса 1
probab_one_valid = probabilities_valid[:, 1]
# AUC-ROC для модели дерева решений
roc_auc_valid = roc_auc_score(target_valid, probab_one_valid)
if f1_valid > best_f1:
best_model = model
best_depth = depth
best_est = est
best_f1 = f1_valid
best_auc_roc = roc_auc_valid
print('Наилучшая модель "случайный лес" для валидационной выборки:', best_model)
print('Количество деревьев наилучшей модели "случайный лес" для валидационной выборки:', best_est)
print('Глубина дерева наилучшей модели "случайный лес" для валидационной выборки:', best_depth)
print('F1-мера наилучшей модели "случайный лес" для валидационной выборки:', best_f1)
print('AUC-ROC наилучшей модели "случайный лес" для валидационной выборки:', best_auc_roc)
<timed exec>:7: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0 Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`
0%| | 0/10 [00:00<?, ?it/s]
Наилучшая модель "случайный лес" для валидационной выборки: RandomForestClassifier(max_depth=9, n_estimators=39, random_state=12345) Количество деревьев наилучшей модели "случайный лес" для валидационной выборки: 39 Глубина дерева наилучшей модели "случайный лес" для валидационной выборки: 9 F1-мера наилучшей модели "случайный лес" для валидационной выборки: 0.624 AUC-ROC наилучшей модели "случайный лес" для валидационной выборки: 0.8673604266824605 CPU times: user 3min 57s, sys: 895 ms, total: 3min 58s Wall time: 3min 59s
Комментарий:
Наилучшая модель "случайный лес" на валидационной выборке имеет F1-меру = 0.624
AUC-ROC = 0.8673604266824605 при количестве деревьев est = 51 и глубине дерева depth = 9. Т.е. F1-мера превышает заданный порог 0,59.
%%time
model = LogisticRegression(random_state=12345, solver='lbfgs', max_iter=1000)
model.fit(features_train_downsampled, target_train_downsampled)
prediction_valid = pd.Series(model.predict(features_valid))
f1_valid = f1_score(target_valid, prediction_valid)
probab_valid = model.predict_proba(features_valid)
probab_valid_one = probab_valid[:, 1]
roc_auc_valid = roc_auc_score(target_valid, probab_valid_one)
print("F1-мера модели 'логистическая регрессия' на валидационной выборке:", f1_valid)
print("AUC-ROC модели 'логистическая регрессия' на валидационной выборке:", roc_auc_valid)
F1-мера модели 'логистическая регрессия' на валидационной выборке: 0.5094991364421416 AUC-ROC модели 'логистическая регрессия' на валидационной выборке: 0.7899394001088915 CPU times: user 182 ms, sys: 184 ms, total: 366 ms Wall time: 342 ms
Комментарий:
Модель "логистическая регрессия" на валидационной выборке имеет F1-меру = 0.5091543156059285
AUC-ROC = 0.7898946712506034, однозначно, что данная модель нам не подходит
Задание 4. Проведём финальное тестирование.
Проверим на тестовой выборке F1-меру и AUC-ROC при помощи модели случайного леса, полученной на сбалансированной в таргете обучающей выборке.
model = RandomForestClassifier(bootstrap = True, class_weight = 'balanced', max_depth= 9,
n_estimators = 51, random_state=12345)
model.fit(features_train_upsampled, target_train_upsampled)
predictions_test = pd.Series(model.predict(features_test))
f1_test = f1_score(target_test, predictions_test)
probabilities_test = model.predict_proba(features_test)
probabilities_one_test = probabilities_test[:, 1]
roc_auc_score_test = roc_auc_score(target_test, probabilities_one_test)
print('F1-мера наилучшей модели "случайный лес" для тестовой выборки:', f1_test)
print('AUC-ROC наилучшей модели "случайный лес" для тестовой выборки:', roc_auc_score_test)
F1-мера наилучшей модели "случайный лес" для тестовой выборки: 0.6238938053097345 AUC-ROC наилучшей модели "случайный лес" для тестовой выборки: 0.8628551509907441
Комментарий:
Обученная модель случайного леса со взвешенными классами имеет достаточную адекватность, подтвержденная ее значением
AUC-ROC = 0.8628551509907441 и F1-меру = 0.6238938053097345 превыщающая заданный порог в 0.59.
fpr_test, tpr_test, thresholds = roc_curve(target_test, probabilities_one_test)
plt.figure()
plt.plot(fpr_test, tpr_test)
# ROC-кривая случайной модели (выглядит как прямая):
plt.plot([0, 1], [0, 1], linestyle='--')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.0])
plt.legend(['ROC-кривая модели случайного леса', 'ROC-кривая случайной модели'])
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('ROC-кривая для модели случайного леса на тестовой выборке при сбалансированном таргете в обучающей выборке')
plt.show()
Комментарий:
На графике наглядно видно, что площадь под ROC-кривой модели случайного леса (AUC-ROC = 0.866754) превышает площадь под ROC-кривой случайной модели (AUC-ROC = 0.5).
В первоначальных данных наблюдался значительный дисбаланс из-за чего обученная на этих данных модель не проходила проверку на адекватность. Все модели не первоначальных данных характеризовались высокой степенью ошибок и низким качеством взвешенной величины (F1) — модели показывали низкие результаты точности и полноты.
По итогу мы устранили дисбаланс классов двумя методами upsampling и downsampling, так мы достигли баланса классо в обучеющей выборки:
1 - 0.51
0 - 0.49
___________________
На новых данных все модели показали результат выше, чем на несбалансированной выборке. Лучшие показатели были у модели случайного леса:
F1 мера = 0.6288659793814434
AUC-ROC = 0.8685557668608516
Финальная модель также прошла проверку на адекватность успешно:
F1 мера = 0.633879781420765
AUC-ROC = 0.8667542735339345
Поставьте 'x' в выполненных пунктах. Далее нажмите Shift+Enter.
Если интересно, существует библиотека атоматического перевода строки в snake-case
с одноименным названием. Ссылка на источник.
И еще библиотека skimpy
с методом clean_columns
. Статья-тьюториал.
Часто бывает удобно быстро посмотреть датасет. Для этого существует множество информативных пакетов для быстрого первичного анализа данных. Если интересно, можешь посмотреть, например это или это 😌.
В теории тренажера предлагается использовать get_dummies
, однако предпочтительным является использование класса OHE из sklearn. По аналогии с масштабированием делаем fit
только на трейне, а transform
на всех выборках.
Дело в том, get_dummies
подходит для анализа данных, а для машинного обучения более предпочтителен OHE
, т.к. он позоволяет избежать ряд ошибок при обучении моделей, в том числе может работать с неизвестными ранее уровнями категорий, которых не было изначально (например, если появится еще одна страна Italy
).
Могу также предложить построить confusion matrix
.
Матрица ошибок — это таблица, которая позволяет визуализировать эффективность алгоритма классификации путем сравнения прогнозируемого значения целевой переменной с ее фактическим значением. Столбцы матрицы представляют наблюдения в прогнозируемом классе, а строки — наблюдения в фактическом классе (или наоборот).
Также для auc-roc
кривой тоже есть метод на sklearn
. Можешь глянуть, если интересно